<?php
/**
 * parseMe: A lightweight RSS/Atom feed reader for mobile and accessibility devices.
 * Copyright (C) 2006-2007 Stephane Daury
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * @version $Id: index.php 68 2007-07-26 16:42:19Z epsi $
 * @author  Stephane Daury: http://stephane.daury.org/
 * @license GNU Public License: http://www.gnu.org/licenses/gpl.html
 */

new parseMe();

class parseMe {
    
    /**
     * Admin-defined configurable options.
     * Associative array containing the application-level settings.
     * @var array
     */ 
    private $_config = array(
        'appName'          => 'parseMe',
        'cache'            => array(
            'directory'    => './cache',
            'timeToLive'   => '30 minutes',
            'secret'       => ''
        ),
        'xmlSourceList'    => array(
            'tekArtist'    => 'http://feeds.feedburner.com/tekArtist',
            'Digg: Tech.'  => 'http://digg.com/rss/containertechnology.xml',
            'Slashdot'     => 'http://rss.slashdot.org/Slashdot/slashdot',
            'Google News'  => 'http://news.google.com/?output=rss'
        ),
        'limits'           => array(
            'increment'    => 5,
            'max'          => 25,
            'default'      => 5,
            'description'  => 1024
        ),
        'order'            => 'feed',
        'googleMobile'     => true
    );

    /**
     * User-agent for connections, updated in __construct().
     * @var string 
     */ 
    private $_userAgent = 'parseMe';
        
    /**
     * Cookie name for source preferences.
     * @var string 
     */ 
    private $_cookieDefaultSources = 'parseMe_defaultSource';
    
    /**
     * Cookie name for items-per-page preferences.
     * @var string 
     */ 
    private $_cookieDefaultLimit   = 'parseMe_defaultLimit';
    
    /**
     * Cookie name for sorting preferences.
     * @var string 
     */ 
    private $_cookieDefaultOrder   = 'parseMe_defaultOrder';
    
    /**
     * Cookie name for Google Mobile Gateway preferences.
     * @var string
     */ 
    private $_cookieDefaultGoogle  = 'parseMe_defaultGoogle';
    
    /**
     * Cache file modification time (Epoch).
     * @var integer
     */ 
    private $_cacheMtime = 0;

    /**
     * Associative array containing the unified data to display, regardless
     * of the feed(s) type.
     * @var array
     */ 
    private $_feedData = array();
    
    /**
     * date() format string used to generate keys for $this->_feedData
     * @var string 
     */ 
    private $_feedDataKeyFormat = 'Y-m-d H:i:s';
    
    /**
     * Valid sorting options
     * @var array
     */ 
    private $_orderTypes = array(
        'feed' => 'By feeds',
        'desc' => 'Newest first',
        'asc'  => 'Oldest first'
    );
        
    /**
     * parseMe homepage, for credits
     * @var string 
     */ 
     private $_parseMeLink = 'http://www.tekartist.org/labs/parseme/';
    
    /**
     * Initializes custom user-agent and assigns values
     * 
     * @param array Admin-defined application settings
     * @return void
     */ 
    public function __construct() {
        $this->_userAgent = $this->_config['appName'].'('.$_SERVER['SCRIPT_URI'].')';
        $this->_cacheMtime = time();
        $this->_init();
    }
    
    /**
     * Gets the user or application default xml sources.
     * 
     * @return array Array keys in $this->_config['xmlSourceList']
     */ 
    private function _getDefaultSources(){
        $xmlSourceList = $this->_config['xmlSourceList'];
        $sourceKeys = array_keys($xmlSourceList);
        $defaultSource = $sourceKeys[0];
        if(isset($_COOKIE[$this->_cookieDefaultSources])){
            $userCookieSources = unserialize(base64_decode($_COOKIE[$this->_cookieDefaultSources]));
            if(is_array($userCookieSources)){
                foreach($userCookieSources as $thisSource){
                    if(isset($xmlSourceList[$thisSource])){
                        $userDefaultSources[] = $thisSource;
                    }
                }
            }
        }
        if(count($userDefaultSources) > 0){
            return $userDefaultSources; 
        }else{
            return array($defaultSource);
        }
    }
    
    /**
     * Gets the user or application default items-per-page limit.
     * 
     * @return integer
     */ 
    private function _getDefaultLimit(){
        $limit = intval($_COOKIE[$this->_cookieDefaultLimit]);
        if($limit > 0 && $limit <= $this->_config['limits']['max']){
            return $limit;
        }
        else{
            return $this->_config['limits']['default'];
        }
    }   
    
    /**
     * Gets the user or application default sort option.
     * 
     * @return string Array key in $this->_orderTypes.
     */ 
    private function _getDefaultOrder(){
        $order = $_COOKIE[$this->_cookieDefaultOrder];
        if(isset($this->_orderTypes[$order])){
            return $order;
        }
        else{
            return $this->_config['order'];
        }
    }
    
    /**
     * Gets the user or application default Google Mobile Gateway pref.
     * 
     * @return boolean
     */ 
    private function _getDefaultGoogle(){
        $useGoogle = unserialize(base64_decode($_COOKIE[$this->_cookieDefaultGoogle]));
        if(    isset($_COOKIE[$this->_cookieDefaultGoogle])
            && ($useGoogle === true || $useGoogle === false)){
            return $useGoogle;
        }
        else{
            return $this->_config['googleMobile'];
        }
    }
    
    /**
     * Generates fake date-based keys to be used with $this->_feedData,
     * when an item does not have a publication date.
     * 
     * @return string Date as defined by $this->_feedDataKeyFormat
     */ 
    private function _bogusDateKey(){
        return date($this->_feedDataKeyFormat,rand(strtotime('yesterday'), time())).' [bogus]';
    }
    
    /**
     * Feed parsing decisional wrapper.
     * 
     * @param object SimpleXML object (http://php.net/simplexml)
     * @return void
     */ 
    private function _parseFeed($xml){
        if(isset($xml->channel)){
            $this->_parseRSS($xml);
        }
        elseif(isset($xml->entry)){
            $this->_parseAtom($xml);
        }
    }
    
    /**
     * RSS handling (http://en.wikipedia.org/wiki/RSS_%28file_format%29).
     * 
     * @param object SimpleXML object (http://php.net/simplexml)
     * @return void
     */ 
    private function _parseRSS($xml){
        
        $nameSpaces = $xml->getDocNamespaces(TRUE);
        $descLimit  = $this->_config['limits']['description'];
        
        $feedTitle  = $xml->channel->title;
        $feedLink   = $xml->channel->link;
    
        if(isset($xml->item)){
            $feedEntries = $xml->item;
        }
        elseif(isset($xml->channel->item)){
             $feedEntries = $xml->channel->item;
        }
        else{
            $feedEntries = false;
        }
        
        if(is_object($feedEntries)){
            
            $keyFormat = $this->_feedDataKeyFormat;
            
            foreach($feedEntries as $entry){

                if(isset($entry->pubDate)){
                    $key = date($keyFormat, strtotime($entry->pubDate));
                    if(isset($this->_feedData[$key])){
                        $key = $this->_bogusDateKey();
                    }
                }
                elseif(isset($nameSpaces['dc'])){
                    // argh, custom namespaces kinda suck w/ simplexml...
                    $entry->registerXPathNamespace('dc', $nameSpaces['dc']);
                    $xpath = $entry->xpath('dc:date');
                    if(list( , $date) = each($xpath[0])){
                        $key = date($keyFormat, strtotime($date));
                        if(isset($this->_feedData[$key])){
                            $key = $this->_bogusDateKey();
                        }
                    }
                }
                else{
                    $key = $this->_bogusDateKey();
                }
                
                $data = array();
                $data['feedTitle'] = strip_tags($feedTitle);
                $data['feedLink']  = strip_tags($feedLink);
                $data['itemTitle'] = strip_tags($entry->title);
                $data['itemLink']  = strip_tags($entry->link);
                
                $itemDesc = strip_tags(str_replace("\n",
                                                   ' ',
                                                   trim($entry->description)));

                if(strlen($itemDesc) > $descLimit){
                    $itemDesc = substr($itemDesc, 0, $descLimit).'...';
                }
                $data['itemDesc']  = $itemDesc;

                $this->_feedData[$key] = $data;
            }
        }
    }
    
    /**
     * Atom handling (http://en.wikipedia.org/wiki/Atom_%28standard%29).
     * 
     * @param  object SimpleXML object (http://php.net/simplexml)
     * @return void
     */ 
    private function _parseAtom($xml){
        
        $descLimit = $this->_config['limits']['description'];
        
        $feedLinkAttributes = $xml->link->attributes();
        
        $feedTitle = $xml->title;
        $feedLink  = $feedLinkAttributes['href'];
    
        if(isset($xml->entry)){
            $feedEntries = $xml->entry;
        }
        else{
            $feedEntries = false;
        }
        
        if(is_object($feedEntries)){
            
            $key = $this->_feedDataKeyFormat;
            
            foreach($feedEntries as $entry){

                if(isset($entry->pubDate)){
                    $key = date($keyFormat, strtotime($entry->pubDate));
                    if(isset($this->_feedData[$key])){
                        $key = $this->_bogusDateKey();
                    }
                }
                else{
                    $key = $this->_bogusDateKey();
                }
                
                $entryAttributes = $entry->link->attributes();
                
                $data = array();
                $data['feedTitle'] = strip_tags($feedTitle);
                $data['feedLink']  = strip_tags($feedLink);
                $data['itemTitle'] = strip_tags($entry->title);
                $data['itemLink']  = strip_tags($entryAttributes['href']);
                
                $itemDesc = strip_tags(str_replace("\n",
                                                   ' ',
                                                   trim($entry->content)));

                if(strlen($itemDesc) > $descLimit){
                    $itemDesc = substr($itemDesc, 0, $descLimit).'...';
                }
                $data['itemDesc']  = $itemDesc;
                
                $this->_feedData[$key] = $data;
            }
        }       
    }

    /**
     * Fetches the feed(s), populates and caches $this->_feedData if needed, or
     * acquires $this->_feedData from existing cache file.
     * 
     * @param  array Array keys in $this->_config['xmlSourceList']
     * @return boolean True on success, false on error
     */ 
    private function _getData($sourceChoices){
        
        $returnVal = false;
        $secret = $this->_config['cache']['secret'];
        $scriptName = basename($_SERVER['PHP_SELF']);

        if($secret == ''){
            print "       <p><strong>Sorry, but you have not defined a new secret key for secure cache filename generation.</strong></p>\n"
                . "       <p>Please be sure to set a random string for \$_config['cache']['secret'] in {$scriptName}.</p>\n";
        }
        else{
            $cacheDir = $this->_config['cache']['directory'];

            if(!function_exists('curl_init')){
                print "       <p><strong>Sorry, but your PHP install is not configured .</strong></p>\n"
                    . "       <p>Please create a writable directory at {$cacheDir}.</p>\n";
            }
            elseif(!file_exists($cacheDir)){
                print "       <p><strong>Sorry, but the cache directory cannot be found.</strong></p>\n"
                    . "       <p>Please create a writable directory at {$cacheDir}.</p>\n";
            }
            elseif(!is_dir($cacheDir)){
                print "       <p><strong>Sorry, but the cache location is not a directory.</strong></p>\n"
                    . "       <p>Please create a writable directory at {$cacheDir}.</p>\n";
            }
            elseif(!is_writable($cacheDir)){
                print "       <p><strong>Sorry, but the cache directory is not writable.</strong></p>\n"
                    . "       <p>Please make the directory writable: {$cacheDir}.</p>\n";
            }
            else{
                $feedData = array();
                $cacheFileName = sha1(serialize($sourceChoices).$secret);
                $cacheDestination = "{$cacheDir}/.{$cacheFileName}.cache";
                
                if(    ( ! file_exists($cacheDestination))
                    || (filemtime($cacheDestination) < strtotime($this->_config['cache']['timeToLive'].' ago')) ){
                    
                    foreach($sourceChoices as $name){
                        $link = $this->_config['xmlSourceList'][$name];
                        $cleanName = htmlspecialchars($name);
                        
                        if(function_exists('curl_init')){
                            // try curl first
                            $curlRes = curl_init($link);
	                        curl_setopt_array($curlRes, array(
	                            CURLOPT_RETURNTRANSFER => 1,
	                            CURLOPT_USERAGENT      => $this->_userAgent
	                        ));                            
	                        
	                        if(($xmlSource = @curl_exec($curlRes)) === false){
	                            print "       <p><strong>Sorry, but \"{$cleanName}\" cannot be reached at this time.</strong> (curl)</p>\n";
	                        }
                        }
                        else{
                            // or use URL-aware fopen wrappers
                            ini_set('user_agent', $this->_userAgent);
                            
                            if(($xmlSource = @file_get_contents($link)) === false){
                                print "       <p><strong>Sorry, but \"{$cleanName}\" cannot be reached at this time.</strong></p>\n";
                            }
                        }
                        
                        if( !isset($xmlSource) || (strlen($xmlSource) == 0) ){
                            print "       <p>Nothing to parse for \"{$cleanName}\".</strong></p>\n";
                        }
                        elseif(($xml = @simplexml_load_string(trim($xmlSource))) === false){
                            print "       <p><strong>Sorry, but \"{$cleanName}\" cannot be parsed at this time.</strong></p>\n";
                        }
                        else{
                            $this->_parseFeed($xml);
                        }           
                    }
                    
                    if(count($this->_feedData) == 0){
                        print "       <p><strong>Sorry, but every feed failed, and there was nothing to cache or display.</strong></p>\n";
                    }
                    else{
                        @file_put_contents($cacheDestination,serialize($this->_feedData));
                        $returnVal = true;
                    }
                }
                else{
                    if(($cache = unserialize(@file_get_contents($cacheDestination))) === false){
                        print "       <p><strong>Sorry, but \"{$cacheFileName}\" cannot be unserialized at this time.</strong></p>\n";
                    }
                    else{
                        $this->_feedData = $cache;
                        $this->_cacheMtime = filemtime($cacheDestination);
                        $returnVal = true;
                    }
                }
            }
        }
        
        return $returnVal;
    }
    
    /**
     * Main processing function.
     * 
     * @return void
     */ 
    private function _init(){

        $xmlSourceList   = $this->_config['xmlSourceList'];
        $cacheDir        = $this->_config['cache']['directory'];
        $limitIncrement  = $this->_config['limits']['increment'];
        $maxLimit        = $this->_config['limits']['max'];
        $defaultLimit    = $this->_config['limits']['default'];
        $defaultOrder    = $this->_config['order'];
        
        $defaultOffset   = 0;
        $cookieLifespan  = strtotime('5 years');
        $googleMobileUrl = 'http://www.google.com/gwt/n?_gwt_noimg=1&amp;u=';
        
        // Get and clean user input
        $sentChoices = isset($_GET['src'])      ? $_GET['src']           : array();
        $limit       = isset($_GET['lmt'])      ? intval($_GET['lmt'])   : $defaultLimit;
        $offset      = isset($_GET['ofst'])     ? intval($_GET['ofst'])  : $defaultOffset;
        $order       = isset($_GET['order'])    ? $_GET['order']         : $defaultOrder;
        $useGoogle   = intval($_GET['gmg']) > 0 ? true                   : false;
        $saveDefault = intval($_GET['sv'])  > 0 ? true                   : false;
        
        if(!is_array($sentChoices)){
            $sentChoices = array();
        }
        
        if(    (count($sentChoices) == 0)
            || (count($sentChoices) > count($xmlSourceList)) ){
            // No or too many choices sent, get default (user | app)
            $sourceChoices = $this->_getDefaultSources();
        }
        else{
            $CleanSources = array();
            foreach($sentChoices as $thisSource){
                if(isset($xmlSourceList[$thisSource])){
                    // Add  as safe source
                    $CleanSources[] = $thisSource;
                }
            }
            
            if(count($CleanSources) == 0){
                // All sent sources are invalid, get default (user | app)
                $sourceChoices = $this->_getDefaultSources();
            }
            else{
                // We have at leats one clean source, move on 
                $sourceChoices = $CleanSources;
                
                if($saveDefault > 0){
                    // Save as a new user default if requested.
                    setcookie(
                        $this->_cookieDefaultSources,
                        base64_encode(serialize($sourceChoices)),
                        $cookieLifespan,
                        '/'
					);
                }
            }
            unset($CleanSources);
        }
        
        if( ( ! isset($_GET['lmt'])) || ($limit < $limitIncrement) || ($limit > $maxLimit) ){
            // Invalid limit sent, get default (user | app)
            $limit = $this->_getDefaultLimit();
        }
        elseif($saveDefault > 0){
            // Save as a new user default if requested.
            setcookie($this->_cookieDefaultLimit, $limit, $cookieLifespan, '/');
        }
        
        if( ($offset < 1) || ($offset > 100) ){
            // Invalid offset sent, use default
            $offset = $defaultOffset;
        }
        
        if( ( ! isset($_GET['order'])) || ( ! isset($this->_orderTypes[$order]))){
            // Invalid order sent, get default (user|app)
            $order = $this->_getDefaultOrder();
        }
        elseif($saveDefault > 0){
            // Save as a new user default if requested.
            setcookie($this->_cookieDefaultOrder, $order, $cookieLifespan, '/');
        }
        
        if( ! isset($_GET['gmg'])){
            // No Google Mobile prefs sent at all, get default (user|app)
            $useGoogle = $this->_getDefaultGoogle();
        }
        elseif($saveDefault > 0){
            // Save as a new user default if requested.
            setcookie(
                $this->_cookieDefaultGoogle,
                base64_encode(serialize($useGoogle)),
                $cookieLifespan,
                '/'
			);
        }
        
        // Set page title and header
        $title = (count($sourceChoices) == 1)
               ? htmlspecialchars($sourceChoices[0])
               : 'Mixed Sources';
        
        // Print head block w/ defined and clean info
        print '<'.'?'.'xml version="1.0"?'.">\n"
            . "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n"
            . "    \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n"
            . "<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\" lang=\"en\">\n"
            . "    <head>\n"
            . "        <title>{$this->_config['appName']} &raquo; {$title}</title>\n"
            . "        <style type=\"text/css\" media=\"all\" title=\"Default\">\n"
            . "            body { font-family: sans-serif; font-size: small; background-color: #fff; }\n"
            . "            h1 { margin:0; font-size: large; }\n"
            . "            a { text-decoration: none; }\n"
            . "            dt { margin-top: 15px; font-weight: bold; }\n"
            . "            dd { margin: 5px 5px 10px 10px; text-align: justified; }\n"
            . "            dd a { font-size: x-small; }\n"
            . "            #pmFooterMeta { font-size: x-small; }\n"
            . "        </style>\n"
            . "        <meta http-equiv=\"Content-Language\" content=\"en\" />\n"
            . "    </head>\n"
            . "    <body>\n"
            . "        <h1>{$title}</h1>\n";
            
        if($this->_getData($sourceChoices)){
            
            $feedData = $this->_feedData;
            
            if($order == 'asc'){
                ksort($feedData);
            }
            elseif($order == 'desc'){
                krsort($feedData);
            }
            
            if(count($feedData) > 0){
                print "        <dl>\n";
                $i = 0;
                foreach($feedData as $date => $entry){
                    if($i >= $offset){
                        printf(
                            '            <dt><a href="%s" target="_blank">%s</a></dt>'."\n",
                            (
                                $useGoogle
                                ? $googleMobileUrl.rawurlencode($entry['itemLink'])
                                : $entry['itemLink']
                            ),
                            $entry['itemTitle']
                        );
                        printf(
                            '            <dd><a href="%s">%s</a>: %s</dd>'."\n",
                            (
                                $useGoogle
                                ? $googleMobileUrl.rawurlencode($entry['feedLink'])
                                : $entry['feedLink']
                            ),
                            $entry['feedTitle'],
                            $entry['itemDesc']
                        );
                        if(($i + 1) >= ($offset + $limit)) break;
                    }
                    $i++;
                }
                print "        </dl>\n";
            }
            
            // Deal with the paging toolbar
            if(count($feedData) > $limit){
                print "        <p>| \n";
    
                $link = $_SERVER['REQUEST_URI'];
                if(strstr($link, 'ofst=') === false) $link .= '?';
            
                // "Previous" link
                if($offset > 0){
                    if(strstr($link, 'ofst=')) $link = preg_replace('/(\&ofst=\d{1,2})/','',$link);
                    $link .= '&ofst='.($offset - $limit);
                    print "            <a href=\"".htmlspecialchars($link)."\">&laquo; previous</a>\n";
                }
                
                // "Next" link
                if(count($feedData) > ($limit + $offset)){
                    if($offset > 0) print "            | \n";
                    if(strstr($link, 'ofst=')) $link = preg_replace('/(\&ofst=\d{1,2})/','',$link);
                    $link .= '&ofst='.($limit + $offset);
                    print "            <a href=\"".htmlspecialchars($link)."\">next &raquo;</a>\n";
                }
        
                print "        |</p>\n";
            }
            
            // Print the cache specs
            $cacheMtime  = $this->_cacheMtime;
            $parseMeLink = $this->_parseMeLink;
            
            if($useGoogle){
                $parseMeLink = $googleMobileUrl.rawurlencode($parseMeLink);
            }
            
            print "        <p id=\"pmFooterMeta\">\n"
                . "            Feed(s) cached on ".date('Y-m-d \a\t H:i:s T',$cacheMtime)."<br />\n"
                . "            ".date('i \m\i\n. s \s\e\c.',( 1800 - (time() - $cacheMtime) ))." to refresh.<br />\n"
                . "            Powered by <a href=\"{$parseMeLink}\">parseMe</a>\n"
                . "        </p>\n"
                . "        <form action=\"./\" method=\"get\">\n"
                . "        <fieldset>\n"
                . "            <p>\n"
                . "                <label for=\"src\">Sources:</label><br />\n";
                
            foreach($xmlSourceList as $sourceName => $sourceURL){
                $checkedSource = in_array($sourceName,$sourceChoices) ? 'checked="checked"' : '';
                print "                <input type=\"checkbox\" name=\"src[]\" value=\"{$sourceName}\" {$checkedSource} />{$sourceName}<br />\n";
            }
            
            // List the available items per page limits
            $checkedLimit = ($limit < $limitIncrement) ? $checkedLimit = 'checked="checked"' : '';
            
            print "            </p>\n"
                . "            <p>\n"
                . "                <label for=\"lmt\">Items/page:</label><br />\n"
                . "                <input type=\"radio\" name=\"lmt\" value=\"1\"  {$checkedLimit}>1\n";
            
            for($i=$limitIncrement; $i<=$maxLimit; $i+=$limitIncrement){
                $checkedLimit = ($i == $limit) ? $checkedLimit = 'checked="checked"' : '';
                print "                    <input type=\"radio\" name=\"lmt\" value=\"{$i}\" {$checkedLimit}>{$i}\n";
                if($i % ($limitIncrement * 2) != 0) print "                    <br />\n";
            }
            
            // List the available sort options
            print "            </p>\n"
                . "            <p>\n"
                . "                <label for=\"lmt\">Order:</label><br />\n";
            
            foreach($this->_orderTypes as $orderType => $orderCaption){
                $checkedOrder = ($orderType == $order) ? 'checked="checked"' : '';
                print "                <input type=\"radio\" name=\"order\" value=\"{$orderType}\" {$checkedOrder} />{$orderCaption}<br />\n";
            }
                            
            // Opt in/out of using the Google Mobile Gateway
            $googleCheckedYes = $useGoogle  ? 'checked="checked"' : '';
            $googleCheckedNo  = $useGoogle  ? '' : 'checked="checked"';

            print "            </p>\n"
                . "            <p>\n"
                . "                <label for=\"gmg\">Use Google Mobile:</label><br />\n"
                . "                <input type=\"radio\" name=\"gmg\" value=\"1\" {$googleCheckedYes} />Yes\n"
                . "                <input type=\"radio\" name=\"gmg\" value=\"0\" {$googleCheckedNo} />No\n"
                . "                <br />\n";
            
            // Save options and end form
            print "            </p>\n"
                . "            <p>\n"
                . "                <label for=\"sv\">Save preferences:</label><br />\n"
                . "                <input type=\"radio\" name=\"sv\" value=\"1\" />Yes\n"
                . "                <input type=\"radio\" name=\"sv\" value=\"0\" checked=\"checked\" />No\n"
                . "            </p>\n"
                . "            <p>\n"
                . "                <input type=\"submit\" name=\"s\" value=\"Go!\" />\n"
                . "            </p>\n"
                . "        </fieldset>\n"
                . "        </form>\n";
        
        }
        
        print "    </body>\n"
                . "</html>\n";
    }

}
?>